Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit f335044ad312a1781f44713d28f60b7d4802eac3


Parents : ae4dfa1
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-03-06T15:49:42-06:00

Add stranger protection features and related UI enhancements

- Updated NotificationBell to handle additional notification types for missed calls and voicemails.
- Added buttons in CallPage for messaging and calling contacts directly from call history.
- Enhanced ContactsPage with context menu options for messaging and calling contacts.
- Introduced a stranger trust banner in ConversationViewer to inform users about non-contacts and provide options to add them.
- Implemented settings in SettingsPage to block attachments and messages from strangers, with corresponding configuration updates.

Changes
Diff

diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
index 8800479c..7c93386c 100644
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ b/meshchatx/src/frontend/components/NotificationBell.vue
@@ -319,9 +319,12 @@ export default {
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
- if (json.type === "lxmf.delivery") {
+ if (
+ json.type === "lxmf.delivery" ||
+ json.type === "telephone_missed_call" ||
+ json.type === "new_voicemail"
+ ) {
await this.loadNotifications();
- // If dropdown is open, mark new notifications as viewed
if (this.isDropdownOpen) {
await this.markNotificationsAsViewed();
}

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 8c108fde..b72e1225 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -777,6 +777,17 @@
>
<MaterialDesignIcon icon-name="account-remove" class="size-4" />
</button>
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all shrink-0"
+ :title="$t('contacts.send_message')"
+ @click="openMessageFromHistory(entry)"
+ >
+ <MaterialDesignIcon
+ icon-name="message-text-outline"
+ class="size-4"
+ />
+ </button>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1 bg-blue-600 text-white rounded-lg text-[10px] font-bold hover:bg-blue-500 transition-all shadow-md shadow-blue-500/10 shrink-0"
@@ -2734,6 +2745,11 @@ export default {
};
this.isContactModalOpen = true;
},
+ openMessageFromHistory(entry) {
+ const hash = entry.remote_destination_hash || entry.remote_identity_hash;
+ if (!hash) return;
+ this.$router.push({ name: "messages", params: { destinationHash: hash } });
+ },
async getHistory(loadMore = false) {
try {
if (!loadMore) {

diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
index 5a4fbf72..610b665c 100644
--- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue
+++ b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
@@ -60,7 +60,7 @@
<div
v-for="contact in contacts"
:key="contact.id"
- class="rounded-2xl border border-gray-100 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/50 px-4 py-3 flex items-center gap-3 hover:border-blue-300 dark:hover:border-blue-700 transition-colors cursor-default"
+ class="group rounded-2xl border border-gray-100 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/50 px-4 py-3 flex items-center gap-3 hover:border-blue-300 dark:hover:border-blue-700 transition-colors cursor-default"
@contextmenu.prevent="openContextMenu($event, contact)"
>
<div class="flex-shrink-0">
@@ -84,6 +84,24 @@
{{ contact.lxmf_address || contact.remote_identity_hash }}
</div>
</div>
+ <div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-blue-100 dark:hover:bg-blue-900/40 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
+ :title="$t('contacts.send_message')"
+ @click.stop="openConversation(contact)"
+ >
+ <MaterialDesignIcon icon-name="message-text-outline" class="size-5" />
+ </button>
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-green-100 dark:hover:bg-green-900/40 hover:text-green-600 dark:hover:text-green-400 transition-colors"
+ :title="$t('contacts.call_contact')"
+ @click.stop="callContact(contact)"
+ >
+ <MaterialDesignIcon icon-name="phone-outline" class="size-5" />
+ </button>
+ </div>
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-700 dark:hover:text-zinc-200 transition-colors"
@@ -114,6 +132,15 @@
class="fixed z-[210] min-w-48 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl"
:style="{ top: `${contextMenu.y}px`, left: `${contextMenu.x}px` }"
>
+ <button type="button" class="context-item" @click="openConversation(contextMenu.contact)">
+ <MaterialDesignIcon icon-name="message-text-outline" class="size-4" />
+ {{ $t("contacts.send_message") }}
+ </button>
+ <button type="button" class="context-item" @click="callContact(contextMenu.contact)">
+ <MaterialDesignIcon icon-name="phone-outline" class="size-4" />
+ {{ $t("contacts.call_contact") }}
+ </button>
+ <div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
<button type="button" class="context-item" @click="shareContact(contextMenu.contact)">
<MaterialDesignIcon icon-name="share-variant" class="size-4" />
{{ $t("contacts.share_contact") }}
@@ -122,6 +149,7 @@
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
{{ $t("contacts.copy_contact_uri") }}
</button>
+ <div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
<button
type="button"
class="context-item text-red-600 dark:text-red-400"
@@ -531,6 +559,19 @@ export default {
ToastUtils.error(this.$t("contacts.failed_remove_contact"));
}
},
+ openConversation(contact) {
+ this.closeContextMenu();
+ const hash = contact.remote_destination_hash || contact.lxmf_address || contact.remote_identity_hash;
+ if (!hash) return;
+ this.$router.push({ name: "messages", params: { destinationHash: hash } });
+ },
+ callContact(contact) {
+ this.closeContextMenu();
+ const hash =
+ contact.remote_telephony_hash || contact.remote_destination_hash || contact.remote_identity_hash;
+ if (!hash) return;
+ this.$router.push({ name: "call", query: { destination_hash: hash, tab: "phone" } });
+ },
openContextMenu(event, contact) {
this.contextMenu.visible = true;
this.contextMenu.contact = contact;

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 1a7dd6a3..bf20c705 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -372,6 +372,42 @@
</div>
</div>
+ <!-- stranger trust banner -->
+ <div
+ v-if="isStrangerPeer && !strangerBannerDismissed"
+ class="mx-3 mt-2 mb-0 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded-lg flex items-center gap-3 text-sm"
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 9v3.75m0 3.75h.008v-.008H12v.008Zm9.303-5.626a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
+ />
+ </svg>
+ <span class="flex-1 text-amber-900 dark:text-amber-200">
+ {{ $t("messages.stranger_banner_text") }}
+ </span>
+ <button
+ class="px-3 py-1 text-xs font-medium rounded-md bg-amber-600 hover:bg-amber-700 text-white transition-colors"
+ @click="addStrangerAsContact"
+ >
+ {{ $t("messages.add_to_contacts") }}
+ </button>
+ <button
+ class="px-2 py-1 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 transition-colors"
+ @click="strangerBannerDismissed = true"
+ >
+ {{ $t("messages.dismiss") }}
+ </button>
+ </div>
+
<!-- chat items -->
<div
id="messages"
@@ -1739,6 +1775,8 @@ export default {
expandedMessageInfo: null,
imageModalUrl: null,
isSelectedPeerBlocked: false,
+ isStrangerPeer: false,
+ strangerBannerDismissed: false,
isGeneratingPaperMessage: false,
generatedPaperMessageUri: null,
isPaperMessageResultModalOpen: false,
@@ -1991,6 +2029,8 @@ export default {
this.saveDraft(oldPeer.destination_hash);
}
this.checkIfSelectedPeerBlocked();
+ this.strangerBannerDismissed = false;
+ this.checkIfStrangerPeer();
this.initialLoad();
if (newPeer) {
this.loadDraft(newPeer.destination_hash);
@@ -2030,6 +2070,9 @@ export default {
// listen for compose new message event
GlobalEmitter.on("compose-new-message", this.onComposeNewMessageEvent);
+ // listen for contact updates to refresh stranger banner
+ GlobalEmitter.on("contact-updated", this.onContactUpdatedForBanner);
+
// check translator
this.checkTranslator();
@@ -2049,6 +2092,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
+ GlobalEmitter.off("contact-updated", this.onContactUpdatedForBanner);
if (this.propagationStatusInterval) {
clearInterval(this.propagationStatusInterval);
}
@@ -2177,6 +2221,54 @@ export default {
(b) => b.destination_hash === this.selectedPeer.destination_hash
);
},
+ onContactUpdatedForBanner(data) {
+ if (this.selectedPeer?.destination_hash === data?.remote_identity_hash) {
+ this.isStrangerPeer = false;
+ this.strangerBannerDismissed = true;
+ }
+ },
+ async checkIfStrangerPeer() {
+ if (!this.selectedPeer) {
+ this.isStrangerPeer = false;
+ return;
+ }
+ if (this.selectedPeer.is_contact || this.selectedPeer.contact_image) {
+ this.isStrangerPeer = false;
+ return;
+ }
+ try {
+ const response = await window.axios.get(
+ `/api/v1/telephone/contacts/check/${this.selectedPeer.destination_hash}`
+ );
+ this.isStrangerPeer = !response.data.is_contact;
+ } catch {
+ this.isStrangerPeer = !this.selectedPeer.is_contact && !this.selectedPeer.contact_image;
+ }
+ },
+ async addStrangerAsContact() {
+ if (!this.selectedPeer) return;
+ try {
+ const displayName =
+ this.selectedPeer.custom_display_name ?? this.selectedPeer.display_name ?? "Unknown";
+ await fetch(`/api/v1/telephone/contacts`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ remote_identity_hash: this.selectedPeer.destination_hash,
+ lxmf_address: this.selectedPeer.destination_hash,
+ name: displayName,
+ }),
+ });
+ this.isStrangerPeer = false;
+ this.strangerBannerDismissed = true;
+ GlobalEmitter.emit("contact-updated", {
+ remote_identity_hash: this.selectedPeer.destination_hash,
+ });
+ this.$emit("reload-conversations");
+ } catch (e) {
+ console.error("Failed to add contact:", e);
+ }
+ },
loadDraft(destinationHash) {
try {
const drafts = JSON.parse(localStorage.getItem("meshchat.drafts") || "{}");

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 655189b4..63981c6d 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -142,6 +142,49 @@
<!-- settings grid -->
<div v-show="hasSearchResults" class="columns-1 lg:columns-2 gap-4 space-y-4">
<!-- Banishment -->
+ <section
+ v-show="matchesSearch('stranger', 'attachments', 'trust', 'block')"
+ class="glass-card break-inside-avoid"
+ >
+ <header class="glass-card__header">
+ <div>
+ <div class="glass-card__eyebrow">Security</div>
+ <h2>{{ $t("app.stranger_protection") }}</h2>
+ <p>{{ $t("app.stranger_protection_description") }}</p>
+ </div>
+ </header>
+ <div class="glass-card__body space-y-4">
+ <label class="setting-toggle">
+ <Toggle
+ id="block-attachments-from-strangers"
+ v-model="config.block_attachments_from_strangers"
+ @update:model-value="onStrangerAttachmentBlockChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("app.block_stranger_attachments")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.block_stranger_attachments_description")
+ }}</span>
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <Toggle
+ id="block-all-from-strangers"
+ v-model="config.block_all_from_strangers"
+ @update:model-value="onBlockAllFromStrangersChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.block_all_from_strangers") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.block_all_from_strangers_description")
+ }}</span>
+ </span>
+ </label>
+ </div>
+ </section>
+
<section
v-show="matchesSearch(...sectionKeywords.banishment)"
class="glass-card break-inside-avoid"
@@ -1630,6 +1673,8 @@ export default {
lxmf_preferred_propagation_node_auto_select: null,
archives_max_storage_gb: 1,
backup_max_count: 5,
+ block_attachments_from_strangers: true,
+ block_all_from_strangers: false,
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
@@ -2147,6 +2192,14 @@ export default {
);
}, 1000);
},
+ async onStrangerAttachmentBlockChange(value) {
+ this.config.block_attachments_from_strangers = value;
+ await this.updateConfig({ block_attachments_from_strangers: value }, "stranger_protection");
+ },
+ async onBlockAllFromStrangersChange(value) {
+ this.config.block_all_from_strangers = value;
+ await this.updateConfig({ block_all_from_strangers: value }, "stranger_protection");
+ },
async onBanishedEffectEnabledChange(value) {
this.config.banished_effect_enabled = value;
await this.updateConfig(


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────